// stolen from http-music

import {
  commandExists,
  killProcess,
  getTimeStrings,
  getTimeStringsFromSec,
} from './general-util.js'

import {spawn} from 'node:child_process'
import {statSync} from 'node:fs'
import {unlink} from 'node:fs/promises'
import EventEmitter from 'node:events'
import path from 'node:path'
import url from 'node:url'

import Socat from './socat.js'

export class Player extends EventEmitter {
  constructor(processOptions = []) {
    super()

    this.processOptions = processOptions

    this.disablePlaybackStatus = false
    this.isLooping = false
    this.isPaused = false
    this.volume = 100
    this.volumeMultiplier = 1.0
  }

  set process(newProcess) {
    this._process = newProcess
    this._process.on('exit', code => {
      if (code !== 0 && !this._killed) {
        this.emit('crashed', code)
      }

      this._killed = false
    })
  }

  get process() {
    return this._process
  }

  playFile(_file, _startTime) {}
  seekAhead(_secs) {}
  seekBack(_secs) {}
  seekTo(_timeInSecs) {}
  seekToStart() {}
  volUp(_amount) {}
  volDown(_amount) {}
  setVolume(_value) {}
  updateVolume() {}
  togglePause() {}
  toggleLoop() {}
  setPause() {}
  setLoop() {}

  async kill() {
    if (this.process) {
      this._killed = true
      await killProcess(this.process)
    }
  }

  printStatusLine(data) {
    // Quick sanity check - we don't want to print the status line if it's
    // disabled! Hopefully printStatusLine won't be called in that case, but
    // if it is, we should be careful.
    if (!this.disablePlaybackStatus) {
      this.emit('printStatusLine', data)
    }
  }

  setVolumeMultiplier(value) {
    this.volumeMultiplier = value
    this.updateVolume()
  }

  fadeIn() {
    const interval = 50
    const duration = 1000
    const delta = 1.0 - this.volumeMultiplier
    const id = setInterval(() => {
      this.volumeMultiplier += delta * interval / duration
      if (this.volumeMultiplier >= 1.0) {
        this.volumeMultiplier = 1.0
        clearInterval(id)
      }
      this.updateVolume()
    }, interval)
  }
}

export class MPVPlayer extends Player {
  // The more powerful MPV player. MPV is virtually impossible for a human
  // being to install; if you're having trouble with it, try the SoX player.

  getMPVOptions(file, startTime) {
    const opts = [
      `--term-status-msg='${this.getMPVStatusMessage()}'`,
      '--no-video',
      file
    ]

    if (this.isLooping) {
      opts.unshift('--loop')
    }

    if (this.isPaused) {
      opts.unshift('--pause')
    }

    if (startTime) {
      opts.unshift('--start=' + startTime)
    }

    opts.unshift('--volume=' + this.volume * this.volumeMultiplier)

    return opts
  }

  getMPVStatusMessage() {
    // Note: This function shouldn't include any single-quotes! It probably
    // (NOTE: PROBABLY) wouldn't cause any security issues, but it will break
    // --term-status-msg parsing and might keep mpv from starting at all.

    return '${=time-pos} ${=duration} ${=percent-pos}'
  }

  playFile(file, startTime) {
    this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions))

    let lastPercent = 0

    this.process.stderr.on('data', data => {
      if (this.disablePlaybackStatus) {
        return
      }

      const match = data.toString().match(
        /([0-9.]+) ([0-9.]+) ([0-9.]+)/
      )

      if (match) {
        const [
          curSecTotal,
          lenSecTotal,
          percent
        ] = match.slice(1)

        if (parseInt(percent) < lastPercent) {
          // mpv forgets commands you sent it whenever it loops, so you
          // have to specify them every time it loops. We do that whenever the
          // position in the track decreases, since that means it may have
          // looped.
          this.setLoop(this.isLooping)
        }

        lastPercent = parseInt(percent)

        this.printStatusLine(getTimeStringsFromSec(curSecTotal, lenSecTotal))
      }

      this.updateVolume();
    })

    return new Promise(resolve => {
      this.process.once('close', resolve)
    })
  }
}

export class ControllableMPVPlayer extends MPVPlayer {
  getMPVOptions(...args) {
    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)]
  }

  playFile(file, startTime) {
    this.removeSocket(this.socketPath)

    do {
      this.socketPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'mtui-socket-' + Math.floor(Math.random() * 10000))
    } while (this.existsSync(this.socketPath))

    this.socat = new Socat(this.socketPath)

    const mpv = super.playFile(file, startTime)

    mpv.then(() => this.removeSocket(this.socketPath))

    return mpv
  }

  existsSync(path) {
    try {
      statSync(path)
      return true
    } catch (error) {
      return false
    }
  }

  sendCommand(...command) {
    if (this.socat) {
      this.socat.send(JSON.stringify({command}))
    }
  }

  seekAhead(secs) {
    this.sendCommand('seek', secs)
  }

  seekBack(secs) {
    this.sendCommand('seek', -secs)
  }

  seekTo(timeInSecs) {
    this.sendCommand('seek', timeInSecs, 'absolute')
  }

  seekToStart() {
    this.seekTo(0)
  }

  volUp(amount) {
    this.setVolume(this.volume + amount)
  }

  volDown(amount) {
    this.setVolume(this.volume - amount)
  }

  setVolume(value) {
    this.volume = value
    this.volume = Math.max(0, this.volume)
    this.volume = Math.min(100, this.volume)
    this.updateVolume()
  }

  updateVolume() {
    this.sendCommand('set_property', 'volume', this.volume * this.volumeMultiplier)
  }

  togglePause() {
    this.isPaused = !this.isPaused
    this.sendCommand('cycle', 'pause')
  }

  toggleLoop() {
    this.isLooping = !this.isLooping
    this.sendCommand('cycle', 'loop')
  }

  setPause(val) {
    const wasPaused = this.isPaused
    this.isPaused = !!val

    if (this.isPaused !== wasPaused) {
      this.sendCommand('cycle', 'pause')
    }

    // For some reason "set pause" doesn't seem to be working anymore:
    // this.sendCommand('set', 'pause', this.isPaused)
  }

  setLoop(val) {
    this.isLooping = !!val
    this.sendCommand('set', 'loop', this.isLooping)
  }

  async kill() {
    const path = this.socketPath
    delete this.socketPath
    if (this.socat) {
      await this.socat.dispose()
      await this.socat.stop()
    }
    await super.kill()
    await this.removeSocket(path)
  }

  async removeSocket(path) {
    if (path) {
      await unlink(path).catch(() => {})
    }
  }
}

export class SoXPlayer extends Player {
  playFile(file, startTime) {
    // SoX's play command is useful for systems that don't have MPV. SoX is
    // much easier to install (and probably more commonly installed, as well).
    // You don't get keyboard controls such as seeking or volume adjusting
    // with SoX, though.

    this._file = file

    this.process = spawn('play', [file].concat(
      this.processOptions,
      startTime ? ['trim', startTime] : []
    ))

    this.process.stdout.on('data', data => {
      process.stdout.write(data.toString())
    })

    // Most output from SoX is given to stderr, for some reason!
    this.process.stderr.on('data', data => {
      // The status line starts with "In:".
      if (data.toString().trim().startsWith('In:')) {
        if (this.disablePlaybackStatus) {
          return
        }

        const timeRegex = String.raw`([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)`
        const match = data.toString().trim().match(new RegExp(
          `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]`
        ))

        if (match) {
          // SoX takes a loooooot of math in order to actually figure out the
          // duration, since it outputs the current time and the remaining time
          // (but not the duration).

          const [
            curHour, curMin, curSec, curSecFrac, // ##:##:##.##
            remHour, remMin, remSec, remSecFrac // ##:##:##.##
          ] = match.slice(2).map(n => parseInt(n))

          const duration = Math.round(
            (curHour + remHour) * 3600 +
            (curMin + remMin) * 60 +
            (curSec + remSec) * 1 +
            (curSecFrac + remSecFrac) / 100
          )

          const lenHour = Math.floor(duration / 3600)
          const lenMin = Math.floor((duration - lenHour * 3600) / 60)
          const lenSec = Math.floor(duration - lenHour * 3600 - lenMin * 60)

          this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
        }
      }
    })

    return new Promise(resolve => {
      this.process.on('close', () => resolve())
    }).then(() => {
      if (this._restartPromise) {
        const p = this._restartPromise
        this._restartPromise = null
        return p
      }
    })
  }

  async seekToStart() {
    // SoX doesn't support a command interface to interact while playback is
    // ongoing. However, we can simulate seeking to the start by restarting
    // playback altogether. We just need to be careful not to resolve the
    // original playback promise before the new one is complete!

    if (!this._file) {
      return
    }

    let resolve = null
    let reject = null

    // The original call of playFile() will yield control to this promise, which
    // we bind to the resolve/reject of a new call to playFile().
    this._restartPromise = new Promise((res, rej) => {
      resolve = res
      reject = rej
    })

    await this.kill()

    this.playFile(this._file).then(resolve, reject)
  }
}

export async function getPlayer(name = null, options = []) {
  if (await commandExists('mpv') && (name === null || name === 'mpv')) {
    return new ControllableMPVPlayer(options)
  } else if (name === 'mpv') {
    return null
  }

  if (await commandExists('play') && (name === null || name === 'sox')) {
    return new SoXPlayer(options)
  } else if (name === 'sox') {
    return null
  }

  return null
}
